# -*- coding: utf-8 -*-
"""
LAN 文件快投（PySide6 + qfluentwidgets）
- 左：在线成员（单击选中/再单击取消） + 发送文件（点击输入框选择/右键清除） + 发送文本
- 右：我的昵称、接收进度（可取消，图标按钮）、消息记录（表格：摘要/详情/复制/删除）
- 传输可取消；速率平滑显示与 ETA；同名文件自动时间戳前缀；昵称改动即时广播
"""
import os
import sys
import json
import socket
import struct
import hashlib
import threading
import time
import errno
import math
from pathlib import Path
from datetime import datetime
from typing import Dict, Tuple

from PySide6.QtCore import Qt, QTimer, QObject, Signal, Slot, QThread, QStandardPaths
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QFileDialog, QHBoxLayout, QVBoxLayout,
    QAbstractItemView, QTableWidgetItem, QLabel, QFrame, QSizePolicy,
    QScrollArea, QHBoxLayout as HLayout, QAbstractScrollArea, QHeaderView,
    QTextEdit, QDialog
)
from qfluentwidgets import (
    setTheme, Theme, TableWidget, PrimaryPushButton, PushButton, LineEdit,
    SubtitleLabel, BodyLabel, InfoBar, InfoBarPosition, ProgressBar,
    ToolButton, FluentIcon
)

# ===== 配置 =====
APP_PORT_DISCOVERY = 39876                 # UDP 心跳端口
APP_PORT_FILE = 39877                      # TCP 文件传输端口
DISCOVERY_INTERVAL = 2.0                   # 心跳间隔
PEER_EXPIRY = 12.0                         # 超时阈值（≥ 间隔的 4~6 倍更稳）
HEARTBEAT_BURST = 2                        # 每次广播突发包数
HEARTBEAT_BURST_GAP = 0.08                 # 突发包间隔（秒）
PEER_REMOVE_GRACE = 10.0                   # 离线宽限期（秒）
APP_MAGIC = "LAN_DROP_V1"                  # 协议标识
BUF = 1024 * 256                           # 传输缓存

# 速率平滑与显示
SPEED_TAU = 2.0                            # 速率 EMA 时间常数（秒）越大越稳
SPEED_LABEL_INTERVAL = 0.3                 # 速率标签最小刷新间隔（秒）

# 消息摘要长度（静态变量，改这里即可）
MSG_PREVIEW_LEN = 16

DEFAULT_NICK = os.environ.get(
    "LANDROP_NICK", os.getenv("USERNAME") or os.getenv("USER") or "User"
)


def get_local_ip() -> str:
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"


def human_size(n: int) -> str:
    units = ["B", "KB", "MB", "GB", "TB"]
    i, f = 0, float(n)
    while f >= 1024 and i < len(units) - 1:
        f /= 1024
        i += 1
    return f"{f:.2f} {units[i]}"


def human_time(seconds: float) -> str:
    s = int(max(0, seconds) + 0.5)
    h, m, sec = s // 3600, (s % 3600) // 60, s % 60
    return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"


# ===== 发现（UDP）=====
class DiscoveryService(QObject):
    peerUpdated = Signal(dict)  # { ip: {nick, ip, file_port, first_seen, last_seen, offline_since?} }

    def __init__(self, nick: str, file_port: int):
        super().__init__()
        self.nick = nick
        self.file_port = file_port
        self.running = False
        self.peers: Dict[str, dict] = {}
        self.lock = threading.Lock()

    def start(self):
        self.running = True
        threading.Thread(target=self._recv_loop, daemon=True).start()
        threading.Thread(target=self._send_loop, daemon=True).start()
        self.gc = QTimer(self)
        self.gc.timeout.connect(self._gc)
        self.gc.start(1000)

    def stop(self):
        self.running = False

    def set_nick(self, nick: str):
        """动态修改昵称并立即突发广播，提高对端“秒刷新”的概率"""
        self.nick = nick or "User"

        def burst():
            try:
                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
                payload = json.dumps({
                    "magic": APP_MAGIC, "type": "hello",
                    "nick": self.nick, "file_port": self.file_port
                }).encode("utf-8")
                for _ in range(HEARTBEAT_BURST):
                    sock.sendto(payload, ("255.255.255.255", APP_PORT_DISCOVERY))
                    time.sleep(HEARTBEAT_BURST_GAP)
                sock.close()
            except Exception:
                pass

        threading.Thread(target=burst, daemon=True).start()

    def _send_loop(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        while self.running:
            try:
                payload = json.dumps({
                    "magic": APP_MAGIC, "type": "hello",
                    "nick": self.nick, "file_port": self.file_port
                }).encode("utf-8")
                for _ in range(HEARTBEAT_BURST):
                    sock.sendto(payload, ("255.255.255.255", APP_PORT_DISCOVERY))
                    time.sleep(HEARTBEAT_BURST_GAP)
            except Exception:
                pass
            time.sleep(max(0.0, DISCOVERY_INTERVAL - HEARTBEAT_BURST * HEARTBEAT_BURST_GAP))

    def _recv_loop(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            sock.bind(("", APP_PORT_DISCOVERY))
        except OSError:
            return
        my_ip = get_local_ip()
        while self.running:
            try:
                data, addr = sock.recvfrom(4096)
                ip = addr[0]
                if ip == my_ip:
                    continue
                msg = json.loads(data.decode("utf-8"))
                if msg.get("magic") != APP_MAGIC or msg.get("type") != "hello":
                    continue
                now = time.monotonic()
                with self.lock:
                    p = self.peers.get(ip)
                    if not p:
                        self.peers[ip] = {
                            "nick": msg.get("nick") or "User",
                            "ip": ip,
                            "file_port": int(msg.get("file_port") or APP_PORT_FILE),
                            "first_seen": now,
                            "last_seen": now
                        }
                    else:
                        p["nick"] = msg.get("nick") or p["nick"]
                        p["file_port"] = int(msg.get("file_port") or p["file_port"])
                        p["last_seen"] = now
                        p.pop("offline_since", None)  # 收到心跳，清除离线标记
                self.peerUpdated.emit(self.snapshot())
            except Exception:
                pass

    def _gc(self):
        now = time.monotonic()
        changed = False
        with self.lock:
            for ip, p in list(self.peers.items()):
                last = p.get("last_seen", 0)
                if now - last > PEER_EXPIRY:
                    if "offline_since" not in p:
                        p["offline_since"] = now
                        changed = True
                    elif now - p["offline_since"] > PEER_REMOVE_GRACE:
                        del self.peers[ip]
                        changed = True
                else:
                    if "offline_since" in p:
                        p.pop("offline_since", None)
                        changed = True
        if changed:
            self.peerUpdated.emit(self.snapshot())

    def snapshot(self) -> dict:
        with self.lock:
            return {k: dict(v) for k, v in self.peers.items()}


# ===== 接收端（TCP）=====
class FileServer(QObject):
    recvProgress = Signal(str, int, int)  # name, recvd, total
    recvDone = Signal(str, str)           # name, save_path
    recvError = Signal(str)               # 文案：接收失败/异常/中断/已取消：name
    recvText = Signal(str, str)           # from_ip, text

    def __init__(self, port: int):
        super().__init__()
        self.port = port
        self.running = False
        # 活动接收：用于取消
        self._active: Dict[str, dict] = {}
        self._active_lock = threading.Lock()

    def start(self):
        self.running = True
        threading.Thread(target=self._run, daemon=True).start()

    def stop(self):
        self.running = False

    def cancel_recv(self, name: str) -> bool:
        """请求取消正在接收的文件"""
        with self._active_lock:
            ent = self._active.get(name)
            if not ent:
                return False
            ent["cancel"] = True
            conn = ent.get("conn")
        try:
            if conn:
                try:
                    conn.shutdown(socket.SHUT_RDWR)
                except Exception:
                    pass
                conn.close()
            return True
        except Exception:
            return False

    def _run(self):
        srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        try:
            srv.bind(("", self.port))
            srv.listen(5)
        except OSError as e:
            self.recvError.emit(f"接收端口占用: {e}")
            return

        downloads = Path(
            QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DownloadLocation)
            or str(Path.home() / "Downloads")
        )
        save_dir = downloads / "LANDrop"
        save_dir.mkdir(parents=True, exist_ok=True)

        while self.running:
            try:
                srv.settimeout(1.0)
                try:
                    conn, addr = srv.accept()
                except socket.timeout:
                    continue
                threading.Thread(target=self._handle, args=(conn, addr, save_dir), daemon=True).start()
            except Exception as e:
                self.recvError.emit(str(e))

    def _handle(self, conn: socket.socket, addr: Tuple[str, int], save_dir: Path):
        """处理单个入站连接：支持 text/file；文件可取消；同名加时间戳"""
        name_for_active = None
        with conn:
            try:
                raw = self._recv_exact(conn, 4)
                if not raw:
                    return
                (hdr_len,) = struct.unpack("!I", raw)
                hdr_bytes = self._recv_exact(conn, hdr_len)
                if not hdr_bytes:
                    return
                hdr = json.loads(hdr_bytes.decode("utf-8", errors="replace"))
                if hdr.get("magic") != APP_MAGIC:
                    return

                # 文本消息
                if hdr.get("type") == "text":
                    size = int(hdr.get("size") or 0)
                    data = b""
                    while len(data) < size:
                        chunk = conn.recv(min(BUF, size - len(data)))
                        if not chunk:
                            break
                        data += chunk
                    try:
                        text = data.decode("utf-8", errors="replace")
                    except Exception:
                        text = "<无法解码的文本数据>"
                    self.recvText.emit(addr[0], text)
                    return

                # 文件
                if hdr.get("type") != "file":
                    return

                raw_name = hdr.get("filename", "file.bin")
                name = os.path.basename(raw_name).replace("/", "_").replace("\\", "_") or "file.bin"
                total = int(hdr.get("size") or 0)
                sha256 = hdr.get("sha256")

                # 保存路径：同名加时间戳前缀
                save_path = save_dir / name
                if save_path.exists():
                    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
                    candidate = save_dir / f"{ts}_{name}"
                    i = 1
                    while candidate.exists():
                        candidate = save_dir / f"{ts}_{i}_{name}"
                        i += 1
                    save_path = candidate

                # 登记活动接收
                name_for_active = name
                with self._active_lock:
                    self._active[name_for_active] = {"conn": conn, "path": save_path, "cancel": False}

                hasher = hashlib.sha256()
                recvd = 0
                with open(save_path, "wb") as f:
                    while recvd < total:
                        chunk = conn.recv(min(BUF, total - recvd))
                        if not chunk:
                            break
                        f.write(chunk)
                        hasher.update(chunk)
                        recvd += len(chunk)
                        self.recvProgress.emit(name, recvd, total)

                # 是否取消
                with self._active_lock:
                    canceled = bool(self._active.get(name_for_active, {}).get("cancel"))

                if recvd != total or canceled:
                    save_path.unlink(missing_ok=True)
                    self.recvError.emit(f"已取消：{name}" if canceled else f"接收中断：{name}")
                    return

                if sha256 and hasher.hexdigest() != sha256:
                    save_path.unlink(missing_ok=True)
                    self.recvError.emit(f"校验失败：{name}")
                    return

                self.recvDone.emit(name, str(save_path))

            except Exception as e:
                try:
                    with self._active_lock:
                        canceled = bool(self._active.get(name_for_active or "", {}).get("cancel"))
                except Exception:
                    canceled = False

                if canceled:
                    try:
                        path = self._active.get(name_for_active or "", {}).get("path")
                        if path:
                            Path(path).unlink(missing_ok=True)
                    except Exception:
                        pass
                    self.recvError.emit(f"已取消：{name_for_active or ''}")
                else:
                    self.recvError.emit(f"接收异常：{e}")

            finally:
                if name_for_active:
                    with self._active_lock:
                        self._active.pop(name_for_active, None)

    @staticmethod
    def _recv_exact(conn: socket.socket, n: int) -> bytes:
        buf = b""
        while len(buf) < n:
            chunk = conn.recv(n - len(buf))
            if not chunk:
                return b""
            buf += chunk
        return buf


# ===== 发送端（TCP）=====
class FileSender(QThread):
    progress = Signal(int, int)  # sent, total
    error = Signal(str)
    done = Signal()

    def __init__(self, ip: str, port: int, path: str):
        super().__init__()
        self.ip = ip
        self.port = port
        self.path = path
        self._cancel = threading.Event()
        self._sock: socket.socket | None = None

    def cancel(self):
        self._cancel.set()
        try:
            if self._sock:
                try:
                    self._sock.shutdown(socket.SHUT_RDWR)
                except Exception:
                    pass
                self._sock.close()
        except Exception:
            pass

    def run(self):
        try:
            p = Path(self.path)
            if not (p.exists() and p.is_file()):
                self.error.emit("文件不存在")
                return
            total = p.stat().st_size
            sha256 = self._sha256(p)
            hdr = {
                "magic": APP_MAGIC, "type": "file",
                "filename": p.name, "size": total, "sha256": sha256,
                "ts": datetime.now().isoformat(timespec="seconds")
            }
            b = json.dumps(hdr).encode("utf-8")

            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.settimeout(5.0)
            self._sock = s
            s.connect((self.ip, self.port))
            if self._cancel.is_set():
                self.error.emit("已取消")
                s.close()
                return

            s.sendall(struct.pack("!I", len(b)))
            s.sendall(b)

            sent = 0
            with open(p, "rb") as f:
                for chunk in iter(lambda: f.read(BUF), b""):
                    if self._cancel.is_set():
                        self.error.emit("已取消")
                        s.close()
                        return
                    s.sendall(chunk)
                    sent += len(chunk)
                    self.progress.emit(sent, total)
            s.close()
            self.done.emit()

        except Exception as e:
            if self._cancel.is_set():
                self.error.emit("已取消")
                return
            err_no = getattr(e, "errno", None)
            if isinstance(e, (BrokenPipeError, ConnectionResetError)) or err_no in (errno.EPIPE, errno.ECONNRESET):
                self.error.emit("对方已取消接收")
            else:
                self.error.emit(f"发送失败：{e}")

    @staticmethod
    def _sha256(p: Path) -> str:
        h = hashlib.sha256()
        with open(p, "rb") as f:
            for c in iter(lambda: f.read(1024 * 1024), b""):
                h.update(c)
        return h.hexdigest()


class TextSender(QThread):
    done = Signal()
    error = Signal(str)

    def __init__(self, ip: str, port: int, text: str):
        super().__init__()
        self.ip = ip
        self.port = port
        self.text = text

    def run(self):
        try:
            data = self.text.encode("utf-8")
            hdr = {
                "magic": APP_MAGIC, "type": "text",
                "size": len(data), "ts": datetime.now().isoformat(timespec="seconds")
            }
            b = json.dumps(hdr).encode("utf-8")
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.settimeout(5.0)
            s.connect((self.ip, self.port))
            s.sendall(struct.pack("!I", len(b)))
            s.sendall(b)
            if data:
                s.sendall(data)
            s.close()
            self.done.emit()
        except Exception as e:
            self.error.emit(f"文本发送失败：{e}")


# ===== 可点击输入框（左键选文件，右键直接清除）=====
class ClickableLineEdit(LineEdit):
    clicked = Signal()
    clearRequested = Signal()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setReadOnly(True)
        self.setCursor(Qt.PointingHandCursor)
        self.setPlaceholderText("点击此处选择要发送的文件")
        self.setToolTip("左键选择文件，右键清除")
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)

    def mousePressEvent(self, e):
        if e.button() == Qt.MouseButton.RightButton:
            self.clearRequested.emit()
            e.accept()
            return
        if e.button() == Qt.MouseButton.LeftButton:
            self.clicked.emit()
        super().mousePressEvent(e)

    def keyPressEvent(self, e):
        if e.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Space) or \
           (e.key() == Qt.Key.Key_O and (e.modifiers() & Qt.KeyboardModifier.ControlModifier)):
            self.clicked.emit()
        else:
            super().keyPressEvent(e)


# ===== 主窗体 =====
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("LAN 文件快投（点选成目标 + 昵称可改）")
        central = QWidget(self)
        self.setCentralWidget(central)
        hl = QHBoxLayout(central)
        hl.setContentsMargins(16, 16, 16, 16)
        hl.setSpacing(16)

        # === 右：在线成员 ===
        right = QWidget(central)
        rv = QVBoxLayout(right)
        rv.setContentsMargins(0, 0, 0, 0)
        rv.setSpacing(8)

        # 我的昵称
        nickRow = QWidget()
        nh = HLayout(nickRow)
        nh.setContentsMargins(0, 0, 0, 0)
        nh.setSpacing(8)
        nh.addWidget(SubtitleLabel("我的昵称"))
        self.nickEdit = LineEdit()
        self.nickEdit.setPlaceholderText("输入昵称")
        self.nickEdit.setText(DEFAULT_NICK)
        nickApply = PrimaryPushButton("应用")
        nickApply.clicked.connect(self._apply_nick)
        nh.addWidget(self.nickEdit, 1)
        nh.addWidget(nickApply)
        rv.addWidget(nickRow)

        rv.addWidget(SubtitleLabel("在线成员（单击=选中，再单击=取消）"))
        self.table = TableWidget()
        self.table.setColumnCount(3)
        self.table.setHorizontalHeaderLabels(["昵称", "地址", "状态"])  # 地址 = IP:端口
        self.table.verticalHeader().setVisible(False)

        # 行为设置
        self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)

        # 禁止滚动条（容器不够高时会裁切，这是你之前的要求）
        self.table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.table.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)

        # 列宽策略
        header = self.table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)            # 昵称
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)   # 地址
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)   # 状态

        self.table.clicked.connect(self._toggle_select_and_update_target)
        self.table.doubleClicked.connect(self._toggle_select_and_update_target)

        rv.addWidget(self.table)

        # === 右：发送文件 ===
        rv.addWidget(SubtitleLabel("发送文件"))
        self.targetLine = LineEdit()
        self.targetLine.setPlaceholderText("从上方列表选择在线成员后，这里会自动填充 IP:端口")
        self.targetLine.setReadOnly(True)

        self.fileLine = ClickableLineEdit()
        self.fileLine.clicked.connect(self._pick_file)
        self.fileLine.clearRequested.connect(self._clear_file)

        btnRow = QWidget()
        bh = HLayout(btnRow)
        bh.setContentsMargins(0, 0, 0, 0)
        bh.setSpacing(8)

        self.sendBtn = PrimaryPushButton("发送")
        self.sendBtn.setEnabled(False)
        self.sendBtn.clicked.connect(self._send)
        self.sendBtn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        self.sendBtn.setMinimumHeight(36)

        self.cancelSendBtn = PushButton("取消发送")
        self.cancelSendBtn.clicked.connect(self._cancel_send)
        self.cancelSendBtn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
        self.cancelSendBtn.hide()

        bh.addWidget(self.sendBtn, 1)
        bh.addWidget(self.cancelSendBtn, 0)

        self.sendProgress = ProgressBar()
        self.sendProgress.setRange(0, 100)
        self.sendProgress.hide()

        rv.addWidget(self.targetLine)
        rv.addWidget(self.fileLine)
        rv.addWidget(btnRow)
        rv.addWidget(self.sendProgress)

        # === 右：发送文本 ===
        rv.addWidget(SubtitleLabel("发送文本"))
        self.textInput = QTextEdit()
        self.textInput.setPlaceholderText("输入要发送的文本（多行）")
        self.textInput.setFixedHeight(90)
        sendTextBtn = PushButton("发送文本")
        sendTextBtn.clicked.connect(self._send_text)
        rv.addWidget(self.textInput)
        rv.addWidget(sendTextBtn)

        # === 左：接收进度 + 消息记录 ===
        left = QWidget(central)
        lv = QVBoxLayout(left)
        lv.setContentsMargins(0, 0, 0, 0)
        lv.setSpacing(12)



        # 接收进度
        lv.addWidget(SubtitleLabel("接收进度"))
        self.recvScroll = QScrollArea()
        self.recvScroll.setWidgetResizable(True)
        self.recvContainer = QWidget()
        self.recvLayout = QVBoxLayout(self.recvContainer)
        self.recvLayout.setContentsMargins(0, 0, 0, 0)
        self.recvLayout.setSpacing(8)
        self.recvLayout.addStretch(1)
        self.recvScroll.setWidget(self.recvContainer)
        lv.addWidget(self.recvScroll, 1)

        # 消息记录（表格）
        lv.addWidget(SubtitleLabel("消息记录"))
        self.msgTable = TableWidget()
        self.msgTable.setColumnCount(4)
        self.msgTable.setHorizontalHeaderLabels(["时间", "对话方", "摘要", "操作"])
        self.msgTable.verticalHeader().setVisible(False)
        self.msgTable.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.msgTable.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        mh = self.msgTable.horizontalHeader()
        mh.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
        mh.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        mh.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
        mh.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
        self.msgTable.setMinimumHeight(160)
        lv.addWidget(self.msgTable)

        hl.addWidget(left, 3)
        hl.addWidget(right, 2)

        # 底部本机信息（显示在右侧末尾）
        self.status = BodyLabel(f"我的IP：{get_local_ip()}   |   我的昵称：{self.nickEdit.text().strip() or 'User'}")
        lv.addWidget(self.status)

        # 后台服务
        self.server = FileServer(APP_PORT_FILE)
        self.server.recvProgress.connect(self._on_recv_progress)
        self.server.recvDone.connect(self._on_recv_done)
        self.server.recvError.connect(self._on_recv_error)
        self.server.recvText.connect(self._on_recv_text)
        self.server.start()

        self.discovery = DiscoveryService(self.nickEdit.text().strip() or "User", APP_PORT_FILE)
        self.discovery.peerUpdated.connect(self._on_peers)
        self.discovery.start()

        # UI：每秒刷新状态
        self._peers: Dict[str, dict] = {}
        self._ui_timer = QTimer(self)
        self._ui_timer.timeout.connect(self._tick_online)
        self._ui_timer.start(1000)

        # 状态/缓存
        self._last_clicked_row = -1
        self._recv_items: Dict[str, dict] = {}    # name -> {wrap,label,bar,total,last_ts,last_bytes,ema_bps,last_label_ts}
        self._msg_records: list[dict] = []        # {"ts","who","text"}
        self.sender: FileSender | None = None

    # === 昵称 ===
    def _apply_nick(self):
        nick = (self.nickEdit.text() or "").strip() or "User"
        self.discovery.set_nick(nick)
        self.status.setText(f"我的IP：{get_local_ip()}   |   我的昵称：{nick}")
        InfoBar.success("昵称已更新", f"当前昵称：{nick}", parent=self, duration=2000,
                        position=InfoBarPosition.TOP_RIGHT)

    # === 接收端 UI ===
    @Slot(str, int, int)
    def _on_recv_progress(self, name: str, recvd: int, total: int):
        item = self._recv_items.get(name)
        if item is None:
            wrap = QFrame()
            wrap.setFrameShape(QFrame.Shape.StyledPanel)
            wrap.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
            v = QVBoxLayout(wrap)
            v.setContentsMargins(12, 8, 12, 8)
            v.setSpacing(6)

            titleRow = QWidget()
            h = HLayout(titleRow)
            h.setContentsMargins(0, 0, 0, 0)
            h.setSpacing(6)
            title = QLabel(name)
            title.setStyleSheet("font-weight:600")
            closeBtn = ToolButton(FluentIcon.CLOSE)
            closeBtn.setToolTip("取消接收 / 移除此记录")
            closeBtn.clicked.connect(lambda _=None, n=name: self._on_recv_close_clicked(n))
            h.addWidget(title)
            h.addStretch(1)
            h.addWidget(closeBtn)

            bar = ProgressBar()
            bar.setRange(0, 100)
            bar.setValue(0)
            info = QLabel("等待中...")

            v.addWidget(titleRow)
            v.addWidget(bar)
            v.addWidget(info)
            self.recvLayout.insertWidget(self.recvLayout.count() - 1, wrap)

            item = {
                "wrap": wrap, "label": info, "bar": bar, "total": total,
                "last_ts": time.time(), "last_bytes": 0,
                "ema_bps": None, "last_label_ts": 0.0
            }
            self._recv_items[name] = item

        percent = int(recvd * 100 / max(1, total))
        item["bar"].setValue(percent)

        now = time.time()
        dt = max(1e-3, now - item.get("last_ts", now))
        ds = max(0, recvd - item.get("last_bytes", 0))
        inst_bps = ds / dt

        alpha = 1.0 - math.exp(-dt / max(1e-3, SPEED_TAU))
        ema = item.get("ema_bps")
        ema = inst_bps if ema is None else (1.0 - alpha) * ema + alpha * inst_bps
        item["ema_bps"] = ema

        item["last_ts"] = now
        item["last_bytes"] = recvd

        if now - item.get("last_label_ts", 0.0) >= SPEED_LABEL_INTERVAL:
            eta_text = ""
            if ema and ema > 1:
                remain = max(0, total - recvd)
                eta_text = f"  |  约剩余 {human_time(remain / ema)}"
            item["label"].setText(f"{human_size(recvd)} / {human_size(total)}  |  {human_size(ema)}/s{eta_text}")
            item["last_label_ts"] = now

    def _on_recv_close_clicked(self, name: str):
        item = self._recv_items.get(name)
        in_progress = bool(item and item["bar"].value() < 100)
        if in_progress and self.server.cancel_recv(name):
            InfoBar.info("正在取消接收", name, parent=self, duration=1500,
                         position=InfoBarPosition.TOP_RIGHT)
        else:
            self._remove_recv_item(name)

    @Slot(str, str)
    def _on_recv_done(self, name: str, path: str):
        item = self._recv_items.get(name)
        if item:
            item["bar"].setValue(100)
            item["label"].setText(f"完成：已保存到 {path}")
        InfoBar.success("接收完成", f"{name}\n已保存到：{path}", parent=self, duration=2500,
                        position=InfoBarPosition.TOP_RIGHT)

    @Slot(str)
    def _on_recv_error(self, msg: str):
        if msg.startswith("已取消"):
            name = msg.split("：", 1)[-1] if "：" in msg else ""
            if name:
                self._remove_recv_item(name)
            InfoBar.info("接收已取消", name, parent=self, duration=1500,
                         position=InfoBarPosition.TOP_RIGHT)
        else:
            InfoBar.error("接收失败", msg, parent=self, duration=3500,
                          position=InfoBarPosition.TOP_RIGHT)

    @Slot(str, str)
    def _on_recv_text(self, ip: str, text: str):
        self._append_log(f"{ip} → 我", text)

    def _remove_recv_item(self, name: str):
        item = self._recv_items.pop(name, None)
        if item:
            w = item["wrap"]
            self.recvLayout.removeWidget(w)
            w.deleteLater()

    # === 文件发送 ===
    def _pick_file(self):
        p, _ = QFileDialog.getOpenFileName(self, "选择文件", "", "所有文件 (*.*)")
        if p:
            self.fileLine.setText(p)
            self._update_send_state()

    def _clear_file(self):
        path = (self.fileLine.text() or "").strip()
        if not path:
            return
        self.fileLine.clear()
        self._update_send_state()
        InfoBar.info("已清除文件", "已移除当前选择的文件", parent=self, duration=1500,
                     position=InfoBarPosition.TOP_RIGHT)

    def _send(self):
        addr = self.targetLine.text().strip()
        if not addr or ":" not in addr:
            InfoBar.warning("请选择目标", "从左侧在线成员表点击一个成员", parent=self)
            return
        ip, port = addr.split(":", 1)
        path = self.fileLine.text().strip()
        if not path:
            InfoBar.warning("请选择文件", "点击输入框选择要发送的文件", parent=self)
            return

        self.sendProgress.setValue(0)
        self.sendProgress.show()
        self.sendBtn.setEnabled(False)
        self.cancelSendBtn.show()

        self.sender = FileSender(ip, int(port), path)
        self.sender.progress.connect(lambda s, tot: self.sendProgress.setValue(int(s * 100 / max(1, tot))))
        self.sender.error.connect(self._on_send_error)
        self.sender.done.connect(self._on_send_done)
        self.sender.start()

    def _cancel_send(self):
        if self.sender and self.sender.isRunning():
            self.sender.cancel()

    @Slot()
    def _on_send_done(self):
        self.sendProgress.hide()
        self.sendBtn.setEnabled(True)
        self.cancelSendBtn.hide()
        InfoBar.success("发送完成", "对方已成功接收", parent=self, duration=2200,
                        position=InfoBarPosition.TOP_RIGHT)
        self.sender = None

    @Slot(str)
    def _on_send_error(self, msg: str):
        self.sendProgress.hide()
        self.sendBtn.setEnabled(True)
        self.cancelSendBtn.hide()
        if msg in ("已取消", "对方已取消接收"):
            InfoBar.info("发送已终止", msg, parent=self, duration=2000,
                         position=InfoBarPosition.TOP_RIGHT)
        else:
            InfoBar.error("发送失败", msg, parent=self, duration=3500,
                          position=InfoBarPosition.TOP_RIGHT)
        self.sender = None

    def _update_send_state(self):
        self.sendBtn.setEnabled(bool(self.targetLine.text()) and bool(self.fileLine.text()))

    # === 文本发送与消息记录 ===
    def _send_text(self):
        addr = self.targetLine.text().strip()
        if not addr or ":" not in addr:
            InfoBar.warning("请选择目标", "从左侧在线成员表点击一个成员", parent=self)
            return
        ip, port = addr.split(":", 1)
        text = self.textInput.toPlainText().strip()
        if not text:
            InfoBar.info("内容为空", "请输入要发送的文本", parent=self, duration=1500)
            return

        sender = TextSender(ip, int(port), text)
        sender.done.connect(lambda: self._append_log(f"我 → {ip}", text))
        sender.error.connect(lambda e: InfoBar.error("文本发送失败", e, parent=self, duration=2500))
        sender.start()
        self._last_text_sender = sender  # 防 GC
        self.textInput.clear()

    def _append_log(self, who: str, text: str):
        ts = datetime.now().strftime("%H:%M:%S")
        self._msg_records.append({"ts": ts, "who": who, "text": text})
        self._rebuild_msg_table()

    def _preview_text(self, text: str) -> str:
        one = (text or "").replace("\n", " ").replace("\r", " ")
        return one if len(one) <= MSG_PREVIEW_LEN else (one[:MSG_PREVIEW_LEN] + "…")

    def _rebuild_msg_table(self):
        self.msgTable.setRowCount(len(self._msg_records))
        for row, rec in enumerate(self._msg_records):
            for col, val in ((0, rec["ts"]), (1, rec["who"]), (2, self._preview_text(rec["text"]))):
                it = self.msgTable.item(row, col)
                if it is None:
                    it = QTableWidgetItem(val)
                    it.setFlags(Qt.ItemFlag.ItemIsEnabled)
                    it.setTextAlignment(Qt.AlignmentFlag.AlignCenter if col != 2
                                        else Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
                    self.msgTable.setItem(row, col, it)
                else:
                    it.setText(val)

            ops = QWidget()
            h = HLayout(ops)
            h.setContentsMargins(0, 0, 0, 0)
            h.setSpacing(6)
            btnDetail = PushButton("详情")
            btnCopy = PushButton("复制")
            btnDel = PushButton("删除")
            btnDetail.clicked.connect(lambda _=None, r=row: self._on_msg_detail(r))
            btnCopy.clicked.connect(lambda _=None, r=row: self._on_msg_copy(r))
            btnDel.clicked.connect(lambda _=None, r=row: self._on_msg_delete(r))
            h.addWidget(btnDetail)
            h.addWidget(btnCopy)
            h.addWidget(btnDel)
            self.msgTable.setCellWidget(row, 3, ops)
        self.msgTable.scrollToBottom()

    def _on_msg_detail(self, row: int):
        if not (0 <= row < len(self._msg_records)):
            return
        full = self._msg_records[row]["text"]

        dlg = QDialog(self)
        dlg.setWindowTitle("消息详情")
        lay = QVBoxLayout(dlg)
        lay.setContentsMargins(12, 12, 12, 12)
        lay.setSpacing(8)

        edit = QTextEdit(dlg)
        edit.setPlainText(full)
        edit.setReadOnly(False)
        edit.setMinimumSize(480, 260)
        lay.addWidget(edit)

        btnRow = QWidget(dlg)
        bh = HLayout(btnRow)
        bh.setContentsMargins(0, 0, 0, 0)
        bh.setSpacing(8)

        btnCopy = PushButton("复制全文")

        def copy_full():
            text = edit.toPlainText()
            if not text.strip():
                return
            QApplication.clipboard().setText(text)
            InfoBar.success("已复制", "消息内容已复制到剪贴板", parent=self,
                            duration=1500, position=InfoBarPosition.TOP_RIGHT)

        btnCopy.clicked.connect(copy_full)
        btnClose = PrimaryPushButton("关闭")
        btnClose.clicked.connect(dlg.accept)
        bh.addStretch(1)
        bh.addWidget(btnCopy)
        bh.addWidget(btnClose)
        lay.addWidget(btnRow)

        dlg.exec()

    def _on_msg_copy(self, row: int):
        if not (0 <= row < len(self._msg_records)):
            return
        full = (self._msg_records[row]["text"] or "")
        if not full.strip():
            return
        QApplication.clipboard().setText(full)
        InfoBar.success("已复制", "消息内容已复制到剪贴板", parent=self, duration=1500,
                        position=InfoBarPosition.TOP_RIGHT)

    def _on_msg_delete(self, row: int):
        if not (0 <= row < len(self._msg_records)):
            return
        self._msg_records.pop(row)
        self._rebuild_msg_table()

    # === 左侧点击 = 选中/取消 ===
    def _toggle_select_and_update_target(self, index):
        row = index.row()
        if row < 0:
            return
        sel = self.table.selectionModel()
        is_selected = any(sel.isSelected(self.table.model().index(row, c)) for c in range(self.table.columnCount()))
        if is_selected and self._last_clicked_row == row:
            self.table.clearSelection()
            self._last_clicked_row = -1
            self.targetLine.setText("")
            self._update_send_state()
        else:
            self.table.blockSignals(True)
            self.table.selectRow(row)
            self.table.blockSignals(False)
            self._last_clicked_row = row
            addr = (self.table.item(row, 1).text() if self.table.item(row, 1) else "").strip()
            self.targetLine.setText(addr)
            self._update_send_state()

    # === 在线列表 ===
    @Slot(dict)
    def _on_peers(self, peers: dict):
        need_rebuild = False
        if not hasattr(self, "_peers"):
            need_rebuild = True
        else:
            old = self._peers
            if set(old.keys()) != set(peers.keys()):
                need_rebuild = True
            else:
                for ip, p in peers.items():
                    op = old.get(ip, {})
                    if (op.get("nick") != p.get("nick")) or (op.get("file_port") != p.get("file_port")):
                        need_rebuild = True
                        break
        self._peers = peers
        if need_rebuild:
            self._rebuild_table()

    def _rebuild_table(self):
        # 记住当前选中的地址
        selected_addr = None
        r = self.table.currentRow()
        if r >= 0 and self.table.item(r, 1):
            selected_addr = self.table.item(r, 1).text()

        items = sorted(self._peers.items(), key=lambda kv: kv[0])  # 按 IP 排
        self.table.setRowCount(len(items))
        for row, (ip, p) in enumerate(items):
            addr = f"{ip}:{p.get('file_port', APP_PORT_FILE)}"

            def put(col, text):
                it = self.table.item(row, col)
                if it is None:
                    it = QTableWidgetItem(text)
                    it.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
                    it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                    self.table.setItem(row, col, it)
                else:
                    it.setText(text)

            put(0, p.get("nick", "User"))
            put(1, addr)
            put(2, "在线" if "offline_since" not in p else "离线")

        if selected_addr:
            for row in range(self.table.rowCount()):
                it = self.table.item(row, 1)
                if it and it.text() == selected_addr:
                    self.table.blockSignals(True)
                    self.table.selectRow(row)
                    self.table.blockSignals(False)
                    self._last_clicked_row = row
                    break

    def _tick_online(self):
        now = time.monotonic()
        for row in range(self.table.rowCount()):
            addr_item = self.table.item(row, 1)
            stat_item = self.table.item(row, 2)
            if not addr_item or not stat_item:
                continue
            ip = (addr_item.text() or "").split(":", 1)[0]
            p = self._peers.get(ip)
            if not p:
                continue
            last = p.get("last_seen", now)
            stat_item.setText("离线" if (now - last > PEER_EXPIRY) else "在线")


if __name__ == "__main__":
    app = QApplication(sys.argv)
    app.setFont(QFont("PingFang SC"))  # macOS：避免 Segoe UI 告警
    setTheme(Theme.LIGHT)

    w = MainWindow()
    w.resize(1100, 720)
    w.show()
    sys.exit(app.exec())
